Skip to content

refactor(chat)!: namespace exports for React Server Component support - #53

Merged
rpvilo merged 4 commits into
mainfrom
feature/rsc-namespace-exports
Aug 3, 2026
Merged

refactor(chat)!: namespace exports for React Server Component support#53
rpvilo merged 4 commits into
mainfrom
feature/rsc-namespace-exports

Conversation

@rpvilo

@rpvilo rpvilo commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Makes the primitives usable from React Server Components, by switching from Object.assign compounds to Base UI-style namespace exports.

The problem

Every compound sub-component resolved to undefined in a server component:

// Server component → "Element type is invalid… but got: undefined"
<Composer><Composer.Container /></Composer>

A server component importing a "use client" module receives a proxy of that module's named exports. It cannot read properties off an exported value — and Object.assign(ComposerRoot, { Container }) is exactly that. Bare <AskUser /> worked; <AskUser.Header /> did not.

The shape

Base UI's structure, verified against their published output: the barrel and the parts layer carry no directive, and it lives one level down on the component module.

src/message/message.tsx        "use client" — MessageRoot, MessageText, …
src/message/index.parts.ts     no directive — export { MessageRoot as Root, … } from "./message"
src/message/index.ts           no directive — export * as Message from "./index.parts"
                                            + flat type / hook re-exports

Unlike Base UI we don't need a file per part — the boundary only has to sit below the barrel — so each component stays in one file and the single-file convention survives.

I proved the mechanism in an isolated spike before touching anything, with both shapes in one Next app:

Shape Server component reaching sub-components
export * as Widget from './widget.parts.js' prerenders, all parts in the HTML, RSC payload present
Object.assign(WidgetRoot, { Label }) Element type is invalid… got: undefined

Breaking change

<Composer><Composer.Root>, and likewise for Message, Thread, Steps, Reasoning, Chip, Attachments, AskUser. Sub-component names, hooks and type names are unchanged, so migration is mechanical. Changeset included.

The build had to change too

A bundle carries one top-level directive, so bundling collapses the per-part boundaries this depends on. The package now ships unbundled — one module per source file, emitted by tsc instead of bunup.

That deleted two workarounds from #51 rather than carrying them forward:

  • dts: { inferTypes: true } is gone. tsc infers declarations natively; there is no isolated-declarations pass to erase Object.assign compounds to unknown.
  • NODE_ENV=production is gone. tsc always emits the production react/jsx-runtime, so the dev-runtime bug can't recur from an unset env var.

One wrinkle worth recording. I first added .js extensions to the src imports, which is the conventional ESM-first approach — and Turbopack refused to resolve ./actions.js against actions.tsx. It has no extensionAlias equivalent, so that broke the app's transpilePackages dev loop, which deliberately compiles package source with no build step. Source therefore stays extensionless and scripts/add-dist-extensions.mjs adds extensions to the emitted output instead (231 specifiers). Dev loop unchanged, dist valid ESM, publint clean.

Tarball grew: 74.5 kB packed across 25 bundled files → ~102 kB across 115 unbundled files. Unpacked is slightly smaller since internal/ helpers are no longer duplicated per entry, but per-file gzip is less efficient. That's the price of correct client boundaries.

Docs

AGENTS.md now describes both layers explicitly, because they legitimately differ: the app layer (components/ai/, components/ui/) keeps Object.assign and stays single-file copy-pasteable, while the package uses namespace exports. Rule 13 forbade barrel files, which the new pattern requires, so it carries an explicit exception. Both invariants that make this work — the barrels staying directive-free, and the build not bundling — are written down where someone would otherwise break them.

Also updated: the package README's RSC section (its constraint is gone, replaced by a server-component transcript example), CHAT_ARCHITECTURE.md, and the content/docs/primitives/* pages — including their source: frontmatter, which pointed at files that moved. COMPOSER.md, THREAD.md, MESSAGE.md and DESIGN_SYSTEM.md document the app layer, so their <Message> examples are still correct and untouched.

Verified

Against the packed tarball, installed into a Next 16 app with no transpilePackages:

  • A server component rendering Thread.Root / Message.Root / Message.Text / Chip.Root / Chip.Icon / Chip.Label / Reasoning.* / Steps.* / AskUser.* prerenders, with all 16 expected data-* part attributes in the HTML
  • A client component using the same namespaces plus useComposer / useThread / useReasoning and event handlers builds and prerenders
  • 179 tests, both typechecks, biome, publint, and the app build (dev path via transpilePackages) all green

@vercel

vercel Bot commented Aug 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
intentface-chat Ready Ready Preview Aug 3, 2026 4:11pm

Request Review

@rpvilo
rpvilo merged commit 6e59e6b into main Aug 3, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant